﻿////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// <copyright>Copyright 2008-2011 Andreas Huber Doenni</copyright>
// Distributed under the Microsoft Public License (Ms-PL).
// See accompanying file License.txt or copy at http://mews.codeplex.com/license.
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

namespace Mews.Net.Nntp.Cache
{
    using System;
    using System.Collections.Generic;
    using System.Data.SqlServerCe;
    using System.Diagnostics.CodeAnalysis;
    using System.IO;
    using System.Net;
    using System.Text;

    /// <summary>Represents a local database of information received from one or more NNTP servers.</summary>
    internal sealed class Database : IDisposable
    {
        private readonly Func<SqlCeDataReader, Host> createHostCallback;
        private readonly Cache cache;
        private readonly string dataDirectory;
        private readonly ICryptoProvider cryptoProvider;
        private SqlCeConnection connection;
        private readonly HostUpdater hostUpdater;
        private readonly GroupUpdater groupUpdater;
        private readonly InboundArticleUpdater inboundArticleUpdater;
        private readonly OutboundArticleUpdater outboundArticleUpdater;
        private readonly SqlCeCommand outboundArticleCountsCommand;

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        /// <summary>See <see cref="IDisposable.Dispose"/>.</summary>
        /// <remarks>Calling this method immediately renders all objects invalid that have been directly or indirectly
        /// retrieved through this object (e.g. <see cref="Host"/> and <see cref="Group"/> objects).</remarks>
        public void Dispose()
        {
            if (this.connection != null)
            {
                this.outboundArticleCountsCommand.Dispose();
                this.outboundArticleUpdater.Dispose();
                this.inboundArticleUpdater.Dispose();
                this.groupUpdater.Dispose();
                this.hostUpdater.Dispose();
                this.connection.Dispose();
                this.connection = null;
            }
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        internal Database(Cache cache, string dataDirectory, ICryptoProvider cryptoProvider)
        {
            if (cryptoProvider == null)
            {
                throw new ArgumentNullException("cryptoProvider");
            }

            this.createHostCallback = this.CreateHost;
            this.cache = cache;
            this.dataDirectory = dataDirectory;
            this.cryptoProvider = cryptoProvider;

            using (var connectionGuard = ScopeGuard.Create(this.connection = Connect(this.dataDirectory)))
            {
                using (var metaData = new MetaData(this))
                {
                    metaData.CheckVersion();
                }

                var commandString =
                    "SELECT " + OutboundArticleNames.SendStatusColumn + ", COUNT(*) FROM " +
                    OutboundArticleNames.Table + " WHERE " + OutboundArticleNames.IsDeletedColumn + " = CONVERT(BIT, 0)" +
                    " GROUP BY " + OutboundArticleNames.SendStatusColumn;

                using (var hostUpdaterGuard = ScopeGuard.Create(this.hostUpdater = new HostUpdater(this.connection)))
                using (var groupUpdaterGuard = ScopeGuard.Create(this.groupUpdater = new GroupUpdater(this.connection)))
                using (var inboundArticleUpdaterGuard = ScopeGuard.Create(
                    this.inboundArticleUpdater = new InboundArticleUpdater(this.connection)))
                using (var outboundArticleUpdaterGuard = ScopeGuard.Create(
                    this.outboundArticleUpdater = new OutboundArticleUpdater(this.connection)))
                {
                    this.outboundArticleCountsCommand = new SqlCeCommand(commandString, this.connection);
                    hostUpdaterGuard.Dismiss();
                    groupUpdaterGuard.Dismiss();
                    inboundArticleUpdaterGuard.Dismiss();
                    outboundArticleUpdaterGuard.Dismiss();
                }

                connectionGuard.Dismiss();
            }
        }

        internal string DataDirectory
        {
            get { return this.dataDirectory; }
        }

        internal SqlCeConnection Connection
        {
            get
            {
                this.AssertNotDisposedOtherObjects();
                return this.connection;
            }
        }

        internal HostUpdater HostUpdater
        {
            get
            {
                this.AssertNotDisposedOtherObjects();
                return this.hostUpdater;
            }
        }

        internal GroupUpdater GroupUpdater
        {
            get
            {
                this.AssertNotDisposedOtherObjects();
                return this.groupUpdater;
            }
        }

        internal InboundArticleUpdater InboundArticleUpdater
        {
            get
            {
                this.AssertNotDisposedOtherObjects();
                return this.inboundArticleUpdater;
            }
        }

        internal OutboundArticleUpdater OutboundArticleUpdater
        {
            get
            {
                this.AssertNotDisposedOtherObjects();
                return this.outboundArticleUpdater;
            }
        }

        internal Host AddHost(
            string name, int port, string userName, string emailAddress, string accountName, string password)
        {
            this.AssertNotDisposedThisObject();

            if ((port < IPEndPoint.MinPort) || (port > IPEndPoint.MaxPort))
            {
                throw new ArgumentOutOfRangeException("port", "Must be >= 0 and <= 65535.");
            }

            return SqlCeHelper.Insert(
                this.createHostCallback,
                this.Connection,
                HostNames.Table,
                HostNames.InsertableColumns,
                name,
                port,
                userName,
                emailAddress,
                this.Encrypt(accountName),
                this.Encrypt(password));
        }

        internal IEnumerable<Host> GetHosts()
        {
            this.AssertNotDisposedThisObject();
            return SqlCeHelper.Select(this.createHostCallback, this.connection, HostNames.Table);
        }

        internal IEnumerable<OutboundArticle> GetOutboundArticles(SendStatus sendStatus)
        {
            return this.GetOutboundArticles(false, sendStatus);
        }

        internal IEnumerable<OutboundArticle> GetDeletedOutboundArticles()
        {
            return this.GetOutboundArticles(true);
        }

        internal IDictionary<SendStatus, int> GetOutboundArticleCounts()
        {
            var counts = new Dictionary<SendStatus, int>()
            {
                { SendStatus.Draft, 0 }, { SendStatus.Outbox, 0 }, { SendStatus.Sent, 0 }
            };

            using (var reader = this.outboundArticleCountsCommand.ExecuteReader())
            {
                while (reader.Read())
                {
                    counts[(SendStatus)reader[0]] = (int)reader[1];
                }
            }

            return counts;
        }

        internal void AssertSendReceiveNotInProgress()
        {
            if (this.cache != null)
            {
                this.cache.AssertSendReceiveNotInProgress();
            }
        }

        internal byte[] Encrypt(string plainText)
        {
            return this.cryptoProvider.Encrypt(plainText == null ? null : Encoding.UTF8.GetBytes(plainText));
        }

        internal string Decrypt(byte[] cypherText)
        {
            byte[] decrypted = this.cryptoProvider.Decrypt(cypherText);
            return decrypted == null ? null : Encoding.UTF8.GetString(decrypted, 0, decrypted.Length);
        }

        internal static void CreateDatabase(string dataDirectory)
        {
            Directory.CreateDirectory(dataDirectory);

            if ((Directory.GetDirectories(dataDirectory).Length != 0) ||
                (Directory.GetFiles(dataDirectory).Length != 0))
            {
                throw new ArgumentException("The specified directory is not empty.");
            }

            string connectionString = GetConnectionString(GetDatabasePath(dataDirectory));

            using (var engine = new SqlCeEngine(connectionString))
            {
                engine.CreateDatabase();
            }

            using (var connection = new SqlCeConnection(connectionString))
            using (var reader = new StreamReader(typeof(Database).Assembly.GetManifestResourceStream(
                typeof(Database).Namespace + ".CreateDatabase.sql")))
            {
                connection.Open();
                StringBuilder commandText = new StringBuilder(512);
                string currentLine;

                while ((currentLine = reader.ReadLine()) != null)
                {
                    if (currentLine.Trim() == "GO")
                    {
                        ExecuteNonQuery(connection, commandText.ToString());
                        commandText.Length = 0;
                    }
                    else
                    {
                        commandText.AppendLine(currentLine);
                    }
                }

                ExecuteNonQuery(connection, commandText.ToString());
            }
        }

        internal static string GetConnectionString(string databasePath)
        {
            return "Data Source=" + databasePath;
        }

        internal static string GetDatabasePath(string dataDirectory)
        {
            return Path.Combine(dataDirectory, "Cache.sdf");
        }

        ////////////////////////////////////////////////////////////////////////////////////////////////////////////////

        private Host CreateHost(SqlCeDataReader reader)
        {
            return new Host(this, reader);
        }

        private void AssertNotDisposedThisObject()
        {
            if (this.connection == null)
            {
                throw new ObjectDisposedException(this.ToString());
            }
        }

        private void AssertNotDisposedOtherObjects()
        {
            if (this.connection == null)
            {
                throw new InvalidOperationException("The parent Cache object has been disposed.");
            }
        }

        private static SqlCeConnection Connect(string dataDirectory)
        {
            var databasePath = GetDatabasePath(dataDirectory);
            var connection = new SqlCeConnection(GetConnectionString(databasePath));

            try
            {
                connection.Open();
                return connection;
            }
            catch (SqlCeException ex)
            {
                connection.Dispose();

                switch (ex.NativeError)
                {
                    case 25009:
                    case 25046:
                        throw new FileNotFoundException(databasePath);
                    default:
                        throw;
                }
            }
        }

        private static void ExecuteNonQuery(SqlCeConnection connection, string commandText)
        {
            using (var command = new SqlCeCommand(commandText, connection))
            {
                command.ExecuteNonQuery();
            }
        }

        private IEnumerable<OutboundArticle> GetOutboundArticles(params object[] filter)
        {
            using (var hostCommand = CommandHelper.Create(this.connection, HostNames.Table))
            using (var articleCommand = CommandHelper.Create(
                this.connection,
                OutboundArticleNames.Table,
                OutboundArticleNames.IsDeletedSendStatusDateUtcIndex,
                DbRangeOptions.Prefix,
                filter,
                null))
            using (var hostReader = hostCommand.ExecuteReader())
            using (var articleReader = articleCommand.ExecuteReader())
            {
                var rowCache = new RowCache<Host>(hostReader, reader => new Host(this, reader));

                while (articleReader.Read())
                {
                    yield return new OutboundArticle(
                        rowCache.GetRow((int)articleReader[OutboundArticleNames.HostIdColumn]), articleReader);
                }
            }
        }

        /// <summary>Represents a cache of rows.</summary>
        /// <typeparam name="TRow">The type representing a row.</typeparam>
        private struct RowCache<TRow>
        {
            private readonly SqlCeDataReader reader;
            private readonly Func<SqlCeDataReader, TRow> createRowCallback;
            private readonly Dictionary<int, TRow> rowDictionary;

            internal RowCache(SqlCeDataReader reader, Func<SqlCeDataReader, TRow> createRowCallback)
            {
                this.reader = reader;
                this.createRowCallback = createRowCallback;
                this.rowDictionary = new Dictionary<int, TRow>();
            }

            internal TRow GetRow(int id)
            {
                TRow row;

                if (!this.rowDictionary.TryGetValue(id, out row))
                {
                    this.reader.SeekAndRead(DbSeekOptions.FirstEqual, id);
                    row = this.createRowCallback(this.reader);
                    this.rowDictionary.Add(id, row);
                }

                return row;
            }
        }
    }
}
